fix(geak): forward the run's actual GPU pin in the handoff - #1321
fix(geak): forward the run's actual GPU pin in the handoff#1321zihaoanllm wants to merge 6 commits into
Conversation
`geak/handoff.json` never carried the run's visible-devices mask. `gpu_ids` was resolved from `HIP_VISIBLE_DEVICES` / `CUDA_VISIBLE_DEVICES` only, so a run pinned the ROCm-canonical way (`ROCR_VISIBLE_DEVICES`) fell through to `0..tp-1`. GEAK writes its own mask for every full server it launches (baseline, profile, config-tuning validation), so every one of them landed on physical GPU 0 regardless of the pin. On a shared host that collides with whatever else holds card 0 and surfaces as `torch.OutOfMemoryError` in the baseline/profile server logs, or as a specialist declining to A/B because the "serving GPU" is held by a foreign co-tenant — indistinguishable from a real `no_gain` result. The two coordinate systems are now explicit: * `gpu_ids` stays a HIP-level device list (HIP indexes into the ROCr-visible set): logical positions inside an inherited ROCR mask, a HIP/CUDA mask verbatim, `0..tp-1` when unpinned. Behaviour is unchanged for every mask that worked before; the ROCR case is now defined instead of accidental. * `gpu_pin` (new, schema_version 3) carries the ABSOLUTE ids plus the variable and source they came from, for consumers that write `ROCR_VISIBLE_DEVICES` themselves. Omitted entirely when no mask is set anywhere — that means "whole machine visible", not "pinned to card 0". The mask is sourced from the materialized baseline recipe's `benchmark.envs` first (what Hyperloom actually benched with), then the process environment, ROCR before HIP/CUDA — the same precedence as `bus/gpu_pool.py` and `policy/gate.py`. The GEAK breakdown collector now records `gpu_ids` / `gpu_pin` so a degraded baseline is diagnosable from the session artifacts instead of by hand. Fixes #1312
xiaofei-zheng
left a comment
There was a problem hiding this comment.
Code review of the GPU-pin handoff change. The direction is right — ROCR_VISIBLE_DEVICES genuinely was the missing link, and forwarding an absolute pin is the correct shape. But as written I believe the recipe-first precedence defeats the fix for HIP/CUDA-pinned runs and makes the documented "unpinned" contract unreachable in production, because materialize_config_with_envs autofills ROCR_VISIBLE_DEVICES=0..tp-1 into every materialized recipe. Details inline; the first two are the ones I would block on.
One cross-cutting note not anchorable to the diff: handoff["tp"] is still read raw from $TP while gpu_ids is now clamped to the mask width, so the two fields can disagree (see the comment on kernel.py).
| anywhere — meaning "whole machine visible", not "pinned to 0". | ||
| """ | ||
| env = os.environ if environ is None else environ | ||
| for source, table in (("baseline_recipe", recipe_envs or {}), ("process_env", env)): |
There was a problem hiding this comment.
Recipe-first precedence defeats the fix for HIP/CUDA-pinned runs.
materialize_config_with_envs unconditionally synthesizes ROCR_VISIBLE_DEVICES=0..tp-1 into benchmark.envs (_workload_envs.py:943-956), and that materialized recipe is what state.baseline_config_path points at by the time KERNEL runs. So the baseline_recipe source always wins, and it always carries a synthetic 0..tp-1 mask.
Repro: run with HIP_VISIBLE_DEVICES=4,5, TP=2, no ROCR anywhere. _resolve_gpu_pin returns {'var': 'ROCR_VISIBLE_DEVICES', 'value': '0,1', 'ids': [0, 1], 'source': 'baseline_recipe'} (verified by executing the helper). handoff["gpu_ids"] becomes "0,1" where the pre-PR code emitted "4,5", and gpu_pin["value"] = "0,1" tells a ROCR-writing consumer to hard-pin physical cards 0 and 1 — recreating exactly the foreign-tenant card-0 collision #1312 is meant to fix, as a new regression for HIP users.
The recipe mask is only meaningful as a pin when it was authored, not when it was autofilled. Either skip the autofilled ROCR key, or consult the process env first and use the recipe only as a fallback.
| if raw is None or str(raw).strip() == "": | ||
| continue | ||
| value = str(raw).strip() | ||
| return {"var": var, "value": value, "ids": _parse_device_list(value), "source": source} |
There was a problem hiding this comment.
The documented {} ("whole machine") case is unreachable in production.
Same root cause as above: because the materialized recipe always carries an autofilled ROCR_VISIBLE_DEVICES=0..tp-1, a genuinely unpinned run still produces a truthy pin. With no mask anywhere in the process env and TP=4, this returns {'ids': [0,1,2,3], 'source': 'baseline_recipe', ...} (verified by executing the helper), so handoff["gpu_pin"] is emitted.
That contradicts the docstring above and the table in docs/components/geak.md ("omitted entirely when no mask is set anywhere, which means whole machine visible, not pinned to card 0"). A GEAK launcher that writes ROCR_VISIBLE_DEVICES = gpu_pin["value"] now restricts a whole-machine run to cards 0-3, where before it inherited everything.
| """ | ||
| env = os.environ if environ is None else environ | ||
| for source, table in (("baseline_recipe", recipe_envs or {}), ("process_env", env)): | ||
| for var in _VISIBLE_DEVICE_VARS: |
There was a problem hiding this comment.
Loop nesting makes source precedence dominate variable precedence.
The source loop is outer and the variable loop inner, so all three vars are checked under baseline_recipe before process_env is reached. That means a recipe-level CUDA_VISIBLE_DEVICES/HIP_VISIBLE_DEVICES silently overrides a real process-level ROCR_VISIBLE_DEVICES pin.
Example: a hand-authored recipe carries benchmark.envs.CUDA_VISIBLE_DEVICES: "0" (a common leftover on a CUDA-derived YAML) while the run is launched with ROCR_VISIBLE_DEVICES=6,7. This returns {'var': 'CUDA_VISIBLE_DEVICES', 'ids': [0]}, and _resolve_handoff_gpu_ids takes the non-ROCR branch and emits gpu_ids="0" verbatim — pinning GEAK to physical card 0 while the run owns 6 and 7.
The docstring's claimed "ROCR_VISIBLE_DEVICES before HIP/CUDA" precedence only holds within a single source. If that ordering is meant to be global, the loops need to be swapped (or the precedence documented as source-major).
| """ | ||
| width = max(int(tp or 1), 1) | ||
| ids = list((gpu_pin or {}).get("ids") or []) | ||
| if not ids: |
There was a problem hiding this comment.
A non-numeric but non-blank mask yields a truthy pin with empty ids, and falls back to card 0.
_resolve_gpu_pin returns early on any non-blank string, but _parse_device_list returns [] for anything non-numeric. Two real cases:
- ROCm accepts GPU UUID masks:
ROCR_VISIBLE_DEVICES=GPU-a1b2c3,GPU-d4e5f6. - YAML parses
ROCR_VISIBLE_DEVICES: [0,1]as a list, andstr()of it is"[0, 1]".
In both, handoff["gpu_pin"] is present with ids: [], this branch sees not ids and returns "0,...,tp-1" — landing every GEAK server on card 0, the exact default the PR exists to eliminate. Meanwhile a consumer reading gpu_pin["ids"] gets nothing, and one writing gpu_pin["value"] exports the literal "[0, 1]".
test_gpu_ids_never_empty_for_a_blank_mask enshrines the fallback as intended, but "never empty" and "never silently card 0" are different goals. Suggest treating a non-blank-but-unparseable mask as an error/warning rather than as unpinned.
| #: ``policy.gate.detect_gpu_count`` so every layer agrees on "the pin". | ||
| #: (Kept local rather than imported from ``gpu_pool``: this module is the pure | ||
| #: helper layer and ``gpu_pool`` drags in the SQLite connection.) | ||
| _VISIBLE_DEVICE_VARS: tuple[str, ...] = ( |
There was a problem hiding this comment.
The "every layer agrees on the pin" claim does not hold for a present-but-empty mask.
gpu_pool._visible_device_mask returns ([], present=True) for VAR="" and gate.detect_gpu_count() derives 0 GPUs from it. This resolver instead skips a blank value and falls through to the next variable.
Divergence: ROCR_VISIBLE_DEVICES="" together with a stale HIP_VISIBLE_DEVICES="2,3". The orchestrator believes it has zero GPUs, while _resolve_gpu_pin returns {'var': 'HIP_VISIBLE_DEVICES', 'ids': [2, 3]} and hands GEAK two cards. test_pin_skips_blank_values locks the divergent behaviour in.
Either match the other layers (blank = "zero visible", stop) or reword this comment so it doesn't claim an agreement that isn't there.
| # The serving/optimization device set, as HIP-level ids (what the | ||
| # consumer exports as HIP_VISIBLE_DEVICES). Logical positions inside | ||
| # an inherited ROCR mask, a HIP/CUDA mask verbatim, else 0..tp-1. | ||
| "gpu_ids": _resolve_handoff_gpu_ids(gpu_pin=gpu_pin, tp=int(os.environ.get("TP", "1") or 1)), |
There was a problem hiding this comment.
gpu_ids logical indices are computed against the recipe mask, but the child inherits the process mask.
The GEAK subprocess is launched with runner_env = dict(os.environ) (line ~1171); nothing overrides runner_env["ROCR_VISIBLE_DEVICES"] from the resolved pin. So whenever the recipe mask and the process mask differ, the logical indices resolve against the wrong set.
This is exactly the case the new test_pin_prefers_recipe_over_process_env encodes: recipe ROCR_VISIBLE_DEVICES="6", process ROCR_VISIBLE_DEVICES="0". The handoff advertises gpu_pin.ids=[6] and gpu_ids="0", but the child inherits ROCR=0, so HIP index 0 is physical card 0 — the handoff claims card 6 while every server GEAK launches sits on card 0. If the recipe mask is to be authoritative, the phase should also export it into runner_env.
Separately, tp and gpu_ids can now disagree. handoff["tp"] a few lines up is still int(os.environ.get("TP")) raw, while gpu_ids is clamped to the mask width. With TP=8 exported on a 4-GPU pod the materializer clamps the recipe to TP=4 / ROCR="0,1,2,3" (test_baseline_param_overrides.py:385), so gpu_ids becomes "0,1,2,3" while tp stays 8 — GEAK launches sglang with --tp 8 and four visible cards and fails to load weights. Pre-PR both fields derived from the same $TP and could not disagree; tp should now come from the same resolved mask/recipe as gpu_ids.
| # Device set + the run's absolute pin: a GEAK baseline that reads | ||
| # `no_gain`/`incomplete` because its servers landed on a foreign | ||
| # tenant's card is otherwise indistinguishable from a real result. | ||
| "gpu_ids": handoff.get("gpu_ids"), |
There was a problem hiding this comment.
These fields are only recorded on the crash-recovery path, so the stated goal is not met.
_geak_reconstruct_from_disk has exactly one call site (line ~906), guarded by if not has_result. A GEAK run that finishes normally and writes geak_result={'status': 'no_gain'} takes the has_result path at line ~900 and returns without ever calling it.
So for every completed run — including the no_gain outcome this comment names — the breakdown contains no gpu_ids/gpu_pin, and a foreign-tenant collision still can't be told apart from a real no_gain. Only a crashed run with no committed result gets the fields. The same two keys need to be recorded on the has_result path too.
| # `no_gain`/`incomplete` because its servers landed on a foreign | ||
| # tenant's card is otherwise indistinguishable from a real result. | ||
| "gpu_ids": handoff.get("gpu_ids"), | ||
| "gpu_pin": handoff.get("gpu_pin"), |
There was a problem hiding this comment.
Writes an explicit null for unpinned runs and for every v1/v2 handoff.
handoff.get("gpu_pin") yields None for any pre-v3 handoff on disk — still produced by any session resumed from before this deploy — and also for a genuinely unpinned v3 run. A breakdown reader then cannot distinguish "no pin was set" from "this handoff predates the field" from "the pin resolved empty".
This also contradicts the writer's own if gpu_pin: guard in kernel.py and the "omitted when nothing is pinned" contract documented in docs/components/geak.md. Suggest inserting the keys only when present, mirroring the writer.
| coord = Coordinator.__new__(Coordinator) | ||
| coord.session_dir = tmp_path | ||
| coord.shared_state = SharedState(baseline_tput=100.0, model_path="/models/m", gpu_type="mi355x") | ||
| coord.phase_kernel._record_geak_kernel_journey = lambda _result: None |
There was a problem hiding this comment.
The end-to-end case never exercises the recipe branch — the branch that wins in production.
This SharedState leaves baseline_config_path unset, so _read_recipe_bench_envs returns {}, _resolve_gpu_pin falls through to the process env, and the source == "process_env" assertion passes.
In every real run baseline_config_path points at a materialized recipe whose benchmark.envs always contains an autofilled ROCR_VISIBLE_DEVICES, so the asserted process_env path is effectively dead code in production — which is why the HIP-pin regression flagged on _resolve_gpu_pin is invisible to the suite.
A case that writes a real materialized YAML (with the autofilled ROCR mask) alongside a HIP_VISIBLE_DEVICES process pin, and asserts the HIP pin survives, would fail today. That is the test this PR most needs.
|
|
||
| | Field | Coordinate system | Value | | ||
| |-------|-------------------|-------| | ||
| | `gpu_ids` | HIP-level device list — HIP indexes into the ROCr-visible set | logical positions inside an inherited `ROCR_VISIBLE_DEVICES` mask, capped at `tp` (`ROCR=6` → `"0"`); a `HIP`/`CUDA` mask verbatim (`HIP=4,5` → `"4,5"`); `0..tp-1` when the run is unpinned | |
There was a problem hiding this comment.
Two inaccuracies in this row.
- "capped at
tp" is only true for the ROCR branch (min(len(ids), width)in_resolve_handoff_gpu_ids). The HIP/CUDA branch forwards the mask with no cap:HIP_VISIBLE_DEVICES=4,5,6,7withTP=2yieldsgpu_ids="4,5,6,7"— four devices for a two-way tensor-parallel launch. - "verbatim" is not accurate either — the ids go through
_parse_device_list, which deduplicates and re-serializes.HIP_VISIBLE_DEVICES=" 4, 4 ,5"produces"4,5", not the original string. The_resolve_handoff_gpu_idsdocstring makes the same "VERBATIM" claim.
Review of #1321 found the resolver defeated by the very autofill it had to account for. `materialize_config_with_envs` writes `ROCR_VISIBLE_DEVICES=0..tp-1` into `benchmark.envs` whenever the mask is absent or narrower than TP, and that materialized recipe is what `state.baseline_config_path` points at by the time KERNEL runs. Reading the recipe first therefore meant the synthetic mask always won: a `HIP`-pinned run shipped `gpu_ids="0,1"` where it used to ship `"4,5"`, and the documented "no mask anywhere => omit gpu_pin" case was unreachable in production. Both re-created the card-0 collision this change exists to remove. - Resolve variable-major (ROCR -> HIP -> CUDA), process env before recipe within each variable. Source-major was wrong in both directions: a leftover recipe CUDA key outranked a real process ROCR pin, and the autofill outranked everything. - Ignore a recipe ROCR value byte-identical to the `0..tp-1` the materializer would have synthesized. - Forward a recipe-only pin as ABSOLUTE ids: the child is launched with `dict(os.environ)` and never inherits that mask, so logical indices would resolve against the wrong set. - Count mask TOKENS, not parsed ids, so a UUID mask maps to the right number of logical slots instead of silently falling back to card 0. Accept a YAML sequence mask. Carry the count in `gpu_pin`. - Take `tp` from the same resolved recipe as `gpu_ids`, so a stale `$TP=8` on a 4-card pod can no longer ship `tp: 8` beside four `gpu_ids`. - Parse the recipe once and share it between `bench_protocol` and the pin. - Record `gpu_ids`/`gpu_pin` on the collector's `has_result` path too — the `no_gain` outcome that needs disambiguating is a COMPLETED run, and the fields were only being written on crash recovery. Insert the keys only when present, matching the writer, so a pre-v3 handoff is not reported as null. - Correct the docs row: the `tp` cap applies to the ROCR branch only, and ids are re-serialized rather than passed through verbatim. Tests: the end-to-end case now builds a real materialized recipe carrying the autofilled mask alongside a HIP process pin; it fails on the previous commit with `gpu_pin.var == ROCR_VISIBLE_DEVICES` and passes here.
CI E2E report — ❌ Timeout
|
|
Hi @xiaofei-zheng , Validated on real hardware. Full Hyperloom e2e run at 110189e on an MI355X node (Qwen3-8B, sglang, TP=1 deliberately pinned to a non-zero card, ROCR_VISIBLE_DEVICES=6). Run completed cleanly (rc=0, no crashes) through PRELUDE → FRAMEWORK → EXPLORE → KERNEL (GEAK, 73 min) → SWEEP → CLOSE. The handoff this run produced: "schema_version": 3, "tp": 1, "gpu_ids": "0", Audited across the whole KERNEL phase (not sampled):
GEAK's own strategy.md records the invariant as HIP_VISIBLE_DEVICES=0 (logical), consistent with _resolve_handoff_gpu_ids emitting logical positions inside an inherited ROCr mask. Also: 26 unit tests pass. |
xiaofei-zheng
left a comment
There was a problem hiding this comment.
Review of the handoff GPU-pin change. 13 findings; the ones marked below under "still falls back to card 0" mean the scenario in #1312 remains reachable on several paths.
Findings 1, 4, 5, 6, 7, 8 and the YAML-sequence one were confirmed by executing the new helpers directly against this branch, not just by reading.
Two non-findings for the record: _resolve_bench_protocol moving from @staticmethod to @classmethod has no external call sites, so nothing breaks; and the new _read_recipe_bench_envs is not registered in coordinator.py's delegation map (~L996) next to _resolve_bench_protocol — harmless today since it is only reached via self. inside PhaseKernel, but inconsistent with the surrounding registry.
| visible = int(pin.get("count") or len(ids) or 0) | ||
| if visible > 0: | ||
| return ",".join(str(i) for i in range(min(visible, width))) | ||
| if not ids: |
There was a problem hiding this comment.
Non-numeric HIP/CUDA mask collapses to 0..tp-1, re-creating the card-0 fallback this PR exists to remove.
With HIP_VISIBLE_DEVICES="GPU-a1b2c3,GPU-d4e5f6" (the ROCm UUID form) and tp=2, _resolve_gpu_pin yields ids=[], count=2. _resolve_handoff_gpu_ids skips the ROCR branch (var != ROCR), falls into if not ids: and returns "0,1". GEAK then exports HIP_VISIBLE_DEVICES=0,1 and every server it launches lands on physical cards 0/1.
Pre-PR the handoff forwarded the UUID string verbatim, so this is a regression on that path. The ROCR branch guards exactly this case with count; the HIP/CUDA branch does not. (Verified by executing the new helpers directly on this branch.)
| raw = table.get(var) | ||
| if raw is None: | ||
| continue | ||
| value = str(raw).strip() if not isinstance(raw, (list, tuple)) else ",".join(str(p) for p in raw) |
There was a problem hiding this comment.
A present-but-empty mask is treated as "unpinned", so a run with zero visible GPUs is reported as owning the whole machine.
ROCR_VISIBLE_DEVICES="" is read as "zero devices visible" by both policy/gate.detect_gpu_count and bus/gpu_pool._visible_device_mask. Here it is skipped instead: _resolve_gpu_pin returns {}, gpu_pin is omitted from the handoff, and gpu_ids becomes "0,1,..." — GEAK launches on physical card 0, which is the precise collision #1312 is about.
The module comment documents the divergence deliberately, but the chosen fallback is the unsafe one. Emitting a pin with count=0 would at least let the consumer refuse to launch.
| _VISIBLE_DEVICE_VARS: tuple[str, ...] = ( | ||
| "ROCR_VISIBLE_DEVICES", | ||
| "HIP_VISIBLE_DEVICES", | ||
| "CUDA_VISIBLE_DEVICES", |
There was a problem hiding this comment.
Only three mask variables are enumerated, so runs pinned via HSA_VISIBLE_DEVICES or GPU_DEVICE_ORDINAL are still unfixed.
common/env_safety.GPU_MASK_ENV_NAMES lists both alongside the three handled here, and multi_node/scripts/launch_multinode.py:429 strips GPU_DEVICE_ORDINAL as a real mask. A pod pinned with HSA_VISIBLE_DEVICES=7 gets gpu_pin omitted and gpu_ids="0", and every GEAK-launched server lands on physical card 0 — the identical failure, in a change whose whole premise is that a mask variable was overlooked.
| return out | ||
|
|
||
|
|
||
| def _is_autofilled_rocr(*, value: str, recipe_envs: Mapping[str, Any]) -> bool: |
There was a problem hiding this comment.
_is_autofilled_rocr bails out to False when the recipe carries no TP key, so the synthetic mask still poses as a real pin.
This is the regression the second commit was added to fix, but it only closes when TP is present. Verified on this branch:
_resolve_gpu_pin(recipe_envs={"ROCR_VISIBLE_DEVICES": "0,1"},
environ={"HIP_VISIBLE_DEVICES": "4,5"})
-> {var: ROCR, ids: [0, 1], source: baseline_recipe}
Any recipe reaching state.baseline_config_path without a TP key — a hand-authored reference recipe, a _grid_server_args variant where TP came from neither env nor YAML, an on-disk recipe written by an older materializer — re-pins GEAK to cards 0,1 while the run actually owns 4,5.
| """ | ||
| env = os.environ if environ is None else environ | ||
| recipe = dict(recipe_envs or {}) | ||
| for var in _VISIBLE_DEVICE_VARS: |
There was a problem hiding this comment.
When both ROCR and HIP are set, the inherited HIP mask is silently discarded and gpu_ids regresses.
ROCR_VISIBLE_DEVICES="4,5,6,7", HIP_VISIBLE_DEVICES="2,3", tp=2: the run actually serves on physical 6,7. Pre-PR gpu_ids="2,3" (correct at the HIP level). Now the ROCR pin wins and gpu_ids="0,1" → physical 4,5, i.e. different cards than the run uses.
cli/preflight._unset_hip_visible_devices only strips the duplicate for CLI-launched runs; the SDK/orchestrator-embedded path, the Ray path and the tests all still reach the KERNEL phase with both set.
| #: is real — lifting the tuple and the parser into a dependency-free shared | ||
| #: module is the right cleanup, but it touches all four call sites and does not | ||
| #: belong in a bugfix.) | ||
| _VISIBLE_DEVICE_VARS: tuple[str, ...] = ( |
There was a problem hiding this comment.
Reuse: _VISIBLE_DEVICE_VARS and _parse_device_list are a fifth copy of a tuple/parser pair already in the repo.
bus/gpu_pool._parse_gpu_list(L60-81) is character-identical to the numeric core of_parse_device_list, and_visible_device_mask(L113) holds the same tuplepolicy/gate.detect_gpu_count(L182) holds it againactions/executors/_ray_serving._VISIBLE_DEVICE_ENV_KEYS(L58) is a literal duplicatecommon/env_safety.GPU_MASK_ENV_NAMES(L217) is the dependency-free shared module this belongs in
The in-code comment acknowledges the duplication and defers it, but precedence ordering now has to stay in sync across five sites, and the semantics have already diverged (empty-mask handling, see the comment above).
| # The serving/optimization device set, as HIP-level ids (what the | ||
| # consumer exports as HIP_VISIBLE_DEVICES). Logical positions inside | ||
| # an inherited ROCR mask, a HIP/CUDA mask as-is, else 0..tp-1. | ||
| "gpu_ids": _resolve_handoff_gpu_ids(gpu_pin=gpu_pin, tp=_tp), |
There was a problem hiding this comment.
gpu_ids is clamped to the ROCR mask width but handoff["tp"] is not, so the handoff can ship tp: 2 next to a single device id.
Process ROCR_VISIBLE_DEVICES="6" with TP=2: _workload_envs.materialize_config_with_envs sees len(rocr_devices)=1 < TP=2, expands the recipe to ROCR="0,1" and writes envs["TP"]=2. _resolve_gpu_pin prefers the process env → count=1; _resolve_handoff_gpu_ids returns min(1,2) → "0"; _tp reads the recipe → 2. Handoff is {tp: 2, gpu_ids: "0"} and GEAK launches sglang --tp 2 against one visible card, failing to load weights.
This is the disagreement the adjacent comment states is impossible. The same case also makes gpu_pin report card 6 while the measured baseline actually ran on the recipe's cards 0,1.
| # ABSOLUTE ids + the var they came from, so a consumer that writes | ||
| # ROCR_VISIBLE_DEVICES itself re-applies the same pin instead of | ||
| # resetting the child to card 0. | ||
| handoff["gpu_pin"] = gpu_pin |
There was a problem hiding this comment.
gpu_pin["value"] and gpu_ids are only mutually consistent when var == ROCR and source == process_env.
For every other pin, a consumer following the documented contract (write ROCR=value, export HIP=gpu_ids) produces out-of-range HIP indices. With HIP_VISIBLE_DEVICES="4,5", tp=2 → gpu_pin={var: HIP, value: "4,5", ids: [4,5]}, gpu_ids="4,5". adapters/launchers/magpie.sh (named in the PR body as the consumer) writes ROCR_VISIBLE_DEVICES=4,5, so the child sees 2 devices; exporting HIP_VISIBLE_DEVICES=4,5 then indexes positions 4 and 5 of a 2-element set. Identical for a recipe-sourced ROCR pin (absolute gpu_ids="6,7" under ROCR=6,7).
docs/components/geak.md documents the asymmetry, but nothing in the payload lets the consumer detect which case it is holding — source is the only signal, and it is not called out as load-bearing.
| try: | ||
| _tp = int(str(_recipe_envs.get("TP") or "").strip() or os.environ.get("TP", "1") or 1) | ||
| except (TypeError, ValueError): | ||
| _tp = int(os.environ.get("TP", "1") or 1) |
There was a problem hiding this comment.
The except (TypeError, ValueError) recovery re-runs an unguarded int() and can raise from inside the handler.
Recipe benchmark.envs.TP: "auto" (or 2.0, which int(str(...)) also rejects) plus TP=auto in the process env: the try body raises ValueError, then the handler evaluates int(os.environ.get("TP", "1") or 1) and raises again during handling, propagating out of _run_geak_kernel_phase and killing the KERNEL phase.
The fallback exists precisely because the primary parse is untrusted, so it should not repeat the same untrusted parse bare.
| # from ``geak_result``, which never carried them — and recorded on THIS | ||
| # path, not just the crash-recovery one, because the outcome that needs | ||
| # disambiguating (`no_gain`) is a completed run. | ||
| _gpu_fields = _handoff_gpu_fields( |
There was a problem hiding this comment.
on_error fires on a missing geak/handoff.json, adding a spurious warning to healthy runs.
FileNotFoundError is an OSError, and common/jsonio.read_json invokes on_error(exc) for OSError before returning the default. Any resumed pre-v3 session, or a run whose GEAK artifacts live under an external exp_root, reaches this unconditional read on the SUCCESS collect path and gets geak: handoff read failed: [Errno 2] No such file or directory appended to the breakdown warnings.
It is also unconditional disk I/O on a path that previously touched no files, for two optional fields — worth gating on the file existing, or passing an on_error that ignores FileNotFoundError.
Second review round on #1321. The pin resolver was correct for the case issue #1312 reported and wrong at most of its edges; this closes all of them and lifts the duplicated mask handling into one module. Pin resolution - Enumerate the full ROCm chain (adds HSA_VISIBLE_DEVICES and GPU_DEVICE_ORDINAL). A run pinned with a legacy spelling was reported as unpinned, i.e. re-pinned to 0..tp-1 — the very bug being fixed. - A present-but-empty mask now yields a pin with count=0 instead of {}. "Zero devices visible" and "whole machine visible" are opposite states and were reported identically. A real pin further down the chain still outranks it. - A HIP-level mask nested inside a ROCr-level pin travels as pin["inner"] and is forwarded as gpu_ids. ROCR=4,5,6,7 with HIP=2,3 is cards 6 and 7; advertising 0,1 moved the servers two cards to the left. - ids and count now both derive from one effective token list (duplicate and negative ordinals dropped), so they can no longer disagree: ROCR="3,3,2" inflated count to 3 and produced logical index 2 of a 2-device set, while HIP="4,4" deflated gpu_ids to one id against tp=2. - A YAML-sequence mask has its elements stripped before they reach pin["value"], which is documented as re-exportable verbatim. - The autofilled-ROCR test applies to HSA_VISIBLE_DEVICES too. Handoff - New gpu_ids_space field ("logical" | "absolute"). gpu_ids alone is ambiguous, and a consumer that guesses wrong re-pins to card 0 — the #1312 failure. Exporting gpu_ids as HIP_VISIBLE_DEVICES stays correct in both spaces; a consumer that writes ROCR itself now has the signal it needs, and docs/components/geak.md states the renumbering rule. Reuse - New dependency-free hyperloom/common/visible_devices.py is the single definition of the mask tuples and the parser, replacing five copies (bus/gpu_pool, policy/gate, actions/executors/_ray_serving, common/env_safety, loop/coordinator_helpers) whose empty-mask semantics had already drifted. The capacity-counting layers keep their narrower COUNTING_VISIBLE_DEVICE_VARS on purpose: widening it would change GPU accounting repo-wide. Tests: 14 new cases in test_geak_handoff_gpu_pin.py. Full inference_optimizer + agents/kernel sweep is 13626 passed; the 15 failures are pre-existing on the base tree (real-ROCm build recipes and a missing claude_agent_sdk), verified by re-running them stashed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pened - test_pin_skips_blank_values named a behaviour that no longer exists. It now documents what it actually guards — a blank mask is recorded, but only as the fallback, so a real pin further down the chain still wins. - gpu_pin["value"] is no longer described as "verbatim": it is trimmed, and a YAML sequence is comma-joined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were flagged as judgement calls in the round-2 push; neither was actually finished. Zero visible devices (r3901236756) - gpu_ids_space gains a third value, "none", for a mask that is set but empty. The pin already reported count=0, but gpu_ids still carried 0..tp-1 with nothing in the payload saying those ids are placeholders, so a consumer reading gpu_ids alone was still told the run owns cards it cannot see. An empty gpu_ids is not the fix: run_e2e.py reads a falsy value as "unset" and falls back to exactly those ids anyway, so it would buy nothing and lose the ability to say why. - The orchestrator logs the condition at ERROR. A GEAK run that dies on an invalid device ordinal is otherwise unexplainable from the logs. - A non-blank mask with no valid ordinal (ROCR="-1") reports zero devices too; a UUID mask, whose ids are empty but whose cards are real, does not. Tuple drift (r3901236799) - COUNTING_VISIBLE_DEVICE_VARS is now DERIVED from VISIBLE_DEVICE_VARS by subtracting an explicit exclusion set, instead of being a second literal guarded only by a comment. A var added to the precedence chain is now counted by default and can only be left out by naming it, so the two cannot silently drift. Value and order are unchanged, asserted. - New test_visible_devices.py pins the partition (every chain member is classified exactly once), the exclusion set's membership, the chain order, the scrub-set coverage, and the parser invariant that keeps gpu_pin["ids"] and ["count"] from disagreeing. Sweep: 13644 passed; the same 15 pre-existing failures as the base tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- ruff format --check: test_every_mask_spelling_counts_as_a_pin had a hand-wrapped signature that fits on one line at the repo's 120-col width. - "Empty except" (code-scanning alert 2733): effective_mask_tokens used try: int(tok) < 0 / except ValueError: pass to let a UUID token fall through. Replaced with an explicit _is_negative_ordinal() predicate, so the non-numeric case is an ordinary False instead of a swallowed exception. Behaviour is unchanged and still covered by test_effective_tokens_drops_negative_ordinals_and_keeps_uuids. - "Unused global variable COUNTING_VISIBLE_DEVICE_VARS" (alert 2742): the module defines the constant for its three consumers but never reads it itself. Added __all__ naming the module's public surface, which is both the fix and the accurate statement of intent. ruff check / ruff format --check clean; 60 tests in test_visible_devices.py and test_geak_handoff_gpu_pin.py pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #1312.
Problem
geak/handoff.jsonnever told GEAK which cards the run owns.phases/kernel.pyresolvedgpu_idsfromHIP_VISIBLE_DEVICES/CUDA_VISIBLE_DEVICESonly —ROCR_VISIBLE_DEVICES,the canonical ROCm pin honoured everywhere else in this repo (
bus/gpu_pool.py,policy/gate.py,cli/preflight.py), was not consulted. So a ROCR-pinned run fell back to0..tp-1and every server GEAK launches (baseline, profile, config-tuning validation) went tophysical GPU 0, OOM-ing against whatever else holds that card and reporting a plausible-looking
no_gain.Fix
ROCR→HIP→CUDAprecedence, recipebenchmark.envsbefore process env.
gpu_pinto the handoff (schema_version3): the absolute ids plus the var they camefrom, for consumers that write
ROCR_VISIBLE_DEVICESthemselves. Omitted when nothing ispinned — that means "whole machine", not "card 0".
gpu_idskeeps its existing HIP-level meaning (HIP indexes into the ROCr-visible set), sinceGEAK's sglang/vllm adapters export it as
HIP_VISIBLE_DEVICES. Only new behaviour: it is nowclamped to the mask when
tpovershoots it.GEAK-side follow-up (not this repo): launch paths that write
ROCR_VISIBLE_DEVICESshould readgpu_pin["value"], asadapters/launchers/magpie.shalready does.Tests
New
test_geak_handoff_gpu_pin.pyplus an end-to-end case intest_geak_resume_recovery.py(
ROCR=7→gpu_pin.ids=[7],gpu_ids="0").pytest src/hyperloom/orchestrator91 passed /14 skipped; ruff and
pylint --errors-onlyclean. Not verified on hardware — I have no repro ofthe xDiT run, so the "server actually lands on the pinned card" leg is untested.